<?php
namespace phiction;
use \phiction\exceptions\bad_request;
use \phiction\exceptions\unimplemented;
use \phiction\exceptions\no_such_page;
use \phiction\exceptions\login as login_required;
use \phiction\exceptions\denied as access_denied;
use \phiction\array_utils as arr;

class mapper
{
    private $is_post;
    private $args;
    private $q;
    private $html;
    private $root_page;

    function __construct(config $config, query_builder $q, html $html)
    {
        $this->is_post = $config->is_post;
        $this->args = $config->args;
        $this->q = $q;
        $this->html = $html;

        $this->root_page = path::root_module_page();
        if (!path::exists($this->root_page)) throw new \LogicException("must have a root page");
    }

    // returns ['type' => MIME_TYPE, 'result' => string]
    function map(string $request_path)
    {
        try {
            $path = self::desugar_request_path($request_path);
            switch ($path['type']) {
                case 'api': return $this->map_api($path);
                case 'page': return $this->map_page($path);
                default: throw new unimplemented("map request path of type " . $path['type']);
            }
        }
        catch (\Throwable $ex) {
            return [
                'type' => 'text/plain',
                'result' => (string)$ex,
            ];
        }
    }

    // returns ['type' => MIME_TYPE, 'result' => string]
    private function map_api(array $path): array
    {
        try {
            if (!$this->is_post) throw new bad_request("can only POST to an api");

            $module = $path['module'];
            $api = path::module_api($module);
            if (!path::exists($api)) throw new bad_request("no such api: $module");

            $action = $path['action'][0];

            $args = $this->args;
            $result = $api::$action($this->q, $args);
            if (method_exists($this->root_page, 'api_result_filter'))
                $result = $this->root_page::api_result_filter($result, $args);
            $result = json_encode(['result' => $result]);
            return [
                'type' => 'application/json',
                'result' => $result,
            ];
        }
        catch (login_required $ex) {
            $result = json_encode(['error' => 'login']);
            return [
                'type' => 'application/json',
                'result' => $result,
            ];
        }
        catch (access_denied $ex) {
            $result = json_encode(['error' => 'denied']);
            return [
                'type' => 'application/json',
                'result' => $result,
            ];
        }
        catch (bad_request $ex) {
            error_log($ex);
            $result = json_encode(['error' => "bad request: " . $ex->getMessage()]);
            return [
                'type' => 'application/json',
                'result' => $result,
            ];
        }
        catch (\Throwable $ex) {
            error_log($ex);
            $result = json_encode(['error' => "internal error: " . $ex->getMessage()]);
            return [
                'type' => 'application/json',
                'result' => $result,
            ];
        }
    }

    // returns ['type' => MIME_TYPE, 'result' => string]
    private function map_page(array $path): array
    {
        try {
            if ($this->is_post) throw new bad_request("can only GET from a page");

            $throw = arr::get_or_null($this->args, 'throw');
            if (is_string($throw)) {
                unset($this->args['throw']);
                if ($throw === 'login' || $throw === 'denied') {
                    $ex = '\phiction\exceptions\\' . $throw;
                    throw new $ex;
                } else {
                    throw new bad_request("no such exception: $throw");
                }
            }

            $module = $path['module'];
            if ($module === '') return $this->map($this->root_page::home());

            $page = path::module_page($module);
            if (!path::exists($page)) throw new no_such_page($module);

            if (!method_exists($page, 'mapping')) throw new no_such_page("page access not allowed: $module");
            $mapping = $page::mapping();

            $action = $path['action'];
            $action = self::match_action($mapping, $action);
            $args = array_merge($this->args, $action['args']);

            $action = $action['name'];
            if ($action[0] === '/') return $this->map(self::instantiate_pattern($action, $args));

            $args['TITLE'] = "";
            if (method_exists($this->root_page, 'title')) $args['TITLE'] = $this->root_page::title();

            $args['BODY'] = $page::$action($this->q, $this->html, $args);
            $result = $this->root_page::skeleton($this->html, $args);
            if (method_exists($this->root_page, 'page_result_filter'))
                $result = $this->root_page::page_result_filter($result, $args);
            $result = $this->html->render($result);
            return [
                'type' => 'text/html',
                'result' => $result,
            ];
        }
        catch (login_required $ex) {
            $this->args['REDIRECT-REFRESH'] = true;
            $this->args['MESSAGE'] = '请登录';
            return $this->map($this->root_page::login());
        }
        catch (access_denied $ex) {
            $this->args['MESSAGE'] = '拒绝访问';
            return $this->map($this->root_page::denied());
        }
        catch (no_such_page $ex) {
            $msg = $this->root_page::no_such_page($ex->getMessage());
            return $this->bad_page($msg);
        }
        catch (\Throwable $ex) {
            error_log($ex);
            return $this->bad_page($ex->getMessage());
        }
    }

    private function bad_page(string $msg)
    {
        return self::render_bad_page($msg, $this->html, $this->root_page);
    }

    static function render_bad_page(string $msg, html $html, $root_page=null)
    {
        if (is_null($root_page)) $root_page = path::root_module_page();
        $args = [ 'TITLE' => "", 'BODY' => $msg ];
        $result = $html->render($root_page::bad_page($html, $args));
        return [
            'type' => 'text/html',
            'result' => $result,
        ];
    }

    // returns ['type' => 'api' | 'page', 'module' => string, 'action' => [...]]
    static function desugar_request_path(string $sugar_path): array
    {
        $path = self::sanitize_path($sugar_path);
        $path = ltrim($path, '/');
        $path = explode('/', $path);

        $type = 'page';
        if ($path[0] === 'api' || $path[0] === 'page')
            $type = array_shift($path);

        $module = '';
        if (count($path) > 0)
            $module = array_shift($path);
        if ($type === 'api' && $module === '')
            throw new \LogicException("no such thing as \"root api\"");
        $module = self::desugar_name($module);

        $action = $path;
        if ($type === 'api') {
            if (count($action) !== 1) throw new \LogicException("api must have exactly 1 action");
            $action[0] = self::desugar_name($action[0]);
        }

        return [
            'type' => $type,
            'module' => $module,
            'action' => $action,
        ];
    }

    static function sanitize_path(string $path): string
    {
        if ($path === '') return '/';
        if (preg_match('#[^-/a-z0-9.]#', $path)) throw new no_such_page("invalid path: $path");
        if (preg_match('#//#', $path)) throw new no_such_page("invalid path: $path");
        return '/' . trim($path, '/');
    }

    static function desugar_name(string $name): string
    {
        return str_replace('-', '_', $name);
    }

    // returns [['match' => bool, 'part' => string], ...]
    static function desugar_pattern(string $pattern): array
    {
        if (preg_match('~[^-/a-z0-9:#]~', $pattern)) throw new \InvalidArgumentException("invalid pattern: $pattern");
        if (preg_match('#//#', $pattern)) throw new \InvalidArgumentException("invalid pattern: $pattern");
        $pattern = trim($pattern, '/');
        if ($pattern === '') return [];

        $pattern = explode('/', $pattern);
        foreach ($pattern as &$part) {
            $match = 'exact';
            if ($part[0] === ':') {
                $match = 'string';
                $part = substr($part, 1);
            } elseif ($part[0] === '#') {
                $match = 'int';
                $part = substr($part, 1);
            }
            $part = [
                'match' => $match,
                'part' => $part,
            ];
        }
        return $pattern;
    }

    static function instantiate_pattern(string $pattern, array $args): string
    {
        $pattern = self::desugar_pattern($pattern);

        foreach ($pattern as &$part) {
            if ($part['match'] === 'exact') {
                $part = $part['part'];
            } else {
                $key = $part['part'];
                if (!array_key_exists($key, $args)) throw \LogicException("no such part: $key");
                $value = $args[$key];
                if($part['match'] === 'int' && !is_int($value)) throw \LogicException("int value required, but got: " . var_export($value, true));
                $part = (string)$value;
            }
        }

        $pattern = '/' . implode('/', $pattern);
        return $pattern;
    }

    // returns ['name' => string, 'args' => [...], 'pattern' => desugared_pattern]
    static function match_action(array $mapping, array $action): array
    {
        $num_parts = count($action);

        foreach ($mapping as $pattern => $name) {
            $pat = self::desugar_pattern($pattern);
            if (count($pat) !== $num_parts) continue;

            $args = self::match_action_to_pattern($pat, $action);
            if (is_null($args)) continue;

            return [
                'name' => $name,
                'args' => $args,
            ];
        }

        $action = '/' . implode('/', $action);
        throw new no_such_page("action: $action");
    }

    // returns null if not match
    // returns [...] if matched
    private static function match_action_to_pattern(array $pattern, array $action)
    {
        $args = [];
        foreach ($pattern as $i => $part) {
            $match = $part['match'];
            $ppart = $part['part'];
            $value = $action[$i];

            if ($match === 'exact') {
                if ($ppart !== $value)
                    return null;
            } else {
                if ($match === 'int') $value = (int)$value;
                $args[$ppart] = $value;
            }
        }
        return $args;
    }
}

